在前面幾篇我們證明了基本的「讀取 → 修改 → 寫入」模式會導致超賣。
後面我們知道了 Go 的 sync.Mutex
,用於保護共享記憶體的厲害工具,防止競態條件。
一個自然大膽的想法浮現在我們腦中:
既然 Mutex
可以解決競爭條件,那我們能不能用它來鎖住「有問題的 Race condition 資料庫操作」,進而解決超賣問題呢?
今天我們就來親手實現這個方式,然後用系統性的方式證明,它為何是一個必然會引發危險的陷阱,
在 Go 應用程式中,宣告一個全域的互斥鎖 (sync.Mutex
)。
任何一個 goroutine 在執行購票的資料庫操作前,都必須先獲得這個鎖。
來修改前幾篇的程式碼:
// 位於 Day4/main.go
// 宣告一個「全域」的互斥鎖,用於保護後續的資料庫操作
var globalTicketMutex sync.Mutex
func purchaseTicketWithAppLock(c *gin.Context) {
// 在執行任何操作前,先鎖定
globalTicketMutex.Lock()
// 確保在函式退出時,不論成功或失敗,都會解鎖
defer globalTicketMutex.Unlock()
// --- 鎖定區域開始 ---
ticketID, err := strconv.Atoi(c.Param("id"))
// ...
// 這裡面是 原本有問題的 Race condition 「讀取 → 修改 → 寫入」邏輯
var currentQuantity int
db.QueryRow("SELECT ...").Scan(¤tQuantity)
if currentQuantity <= 0 {
// ...
return
}
newQuantity := currentQuantity - 1
db.Exec("UPDATE tickets ...", newQuantity, ticketID)
c.JSON(http.StatusOK, gin.H{"message": "成功購買 1 張票券"})
// --- 鎖定區域結束 ---
}
這段程式碼在單一進程內看起來沒問題。
如果我們啟動這一個 Go 應用實例,然後用併發測試腳本去請求它:
# 發送 20 個併發請求
# 讓每一個請求固定只能買一張
for i in {1..20}; do curl -s -X POST http://localhost:8080/tickets/1/purchase-mutex & done; wait
測試結果會讓你非常滿意:初始 1000 張票,最終剩下 980 張。
不多不少,超賣問題消失了!
到這裡我們以為大功告成,然後把程式碼部署上線。之後,問題就大了。
一個經驗豐富的工程師會指出,即便是在看似成功的單體測試,該方案也已埋下兩個嚴重的隱患:
效能災難 (Performance Disaster): globalTicketMutex
是一個全域鎖。
可靠性風險 (Reliability Risk): 如果某個 goroutine 拿到了鎖,但在 defer
執行前,整個應用程式進程 (process) 意外崩潰,這把位於記憶體中的鎖將永遠不會被釋放。
為什麼這個看似完美的方案,實際上是個陷阱?
因為在真實的生產環境中,為了應對流量、保證高可用,你的應用程式永遠不會只部署一個實例。
你至少會部署兩個,甚至數百、數千個。
當你的服務擴展到多個實例時,情況是這樣的:
讓我們來分析這張圖:
負載平衡器 (Load Balancer): 流量被隨機分配到後端的不同應用程式實例上。
兩個獨立的應用實例: 你有「Go 應用 A」和「Go 應用 B」。
兩個獨立的鎖: 應用 A 記憶體中的 globalTicketMutex
(我們稱之為 mu_A
) 和應用 B 記憶體中的 globalTicketMutex
(mu_B
),是兩個完全獨立、互不相識、存在於不同伺服器記憶體中的鎖。
競爭重現:
請求 1 被分配到應用 A。它成功獲取了 mu_A
這把鎖。
幾乎在同一時刻,請求 2 被分配到應用 B。它也成功獲取了 mu_B
這把鎖。
現在,應用 A 和應用 B 都認為自己拿到了鎖,它們同時向那個共享的資料庫發起了「讀取 → 修改 → 寫入」操作。
Day 1 的超賣問題,一模一樣地回來了。 我們加的鎖,完全沒有起到任何作用。
這個陷阱教會了我們一個在分散式系統中至關重要的原則:
應用程式層級的記憶體鎖,永遠不能用於保護位於其外部的共享資源(資料庫)。
簡單來說:鎖必須和你要保護的資源,存在於同一個地方。
要保護 Go 程式記憶體中的一個變數,就用 Go 的 sync.Mutex
。
要保護資料庫中的一筆資料,就必須使用資料庫層級(有效範圍) 的機制。
不能在 A 房間裡用一把鎖,去鎖 B 房間的門。
sync.Mutex
) 去處理資料庫的併發衝突,是**錯誤的抽象層級。可以了解為什麼不能在應用程式層耍小聰明。
我們必須回到問題的根本——資料庫——尋找一個真正可靠的方法。
明天,我們會介紹一個真正有效的解決方案:資料庫的原子 UPDATE
操作。
Go 併發與鎖機制